package org.terramagnet.http;

import org.terramagnet.http.signature.Signer;
import org.terramagnet.http.util.DataUtil;
import org.terramagnet.http.util.TokenQueue;
import org.terramagnet.http.util.Validate;

import javax.net.ssl.*;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.nio.charset.IllegalCharsetNameException;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.X509Certificate;
import java.util.*;
import java.util.zip.GZIPInputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * HTTP连接工具. Implementation of {@link Connection}.
 *
 *
 * @see cn.etuo.outsourcer.kejingmei.framework.http.HttpFacade#connect(String)
 */
public class HttpConnection implements Connection {

    private static final Logger logger = LoggerFactory.getLogger(HttpConnection.class);
    public static final String CONTENT_ENCODING = "Content-Encoding";
    private static final String CONTENT_TYPE = "Content-Type";
    private static final String MULTIPART_FORM_DATA = "multipart/form-data";
    private static final String FORM_URL_ENCODED = "application/x-www-form-urlencoded";

    private static String encodeUrl(String url) {
        if (url == null) {
            return null;
        }
        return url.replaceAll(" ", "%20");
    }

    private static String encodeMimeName(String val) {
        if (val == null) {
            return null;
        }
        return val.replaceAll("\"", "%22");
    }
//----------------------static end---------------------------
    private String url;
    private Map<String, String> pathVariables;
    private Connection.Request req = new Request();
    private Connection.Response res = new Response();

    HttpConnection() {
    }

    @Override
    public Connection url(URL url) {
        req.url(url);
        return this;
    }

    @Override
    public Connection url(String url) {
        Validate.notEmpty(url, "Must supply a valid URL");
        this.url = url;
        return this;
    }

    @Override
    public Connection var(String name, String value) {
        if (pathVariables == null) {
            pathVariables = new HashMap<String, String>();
        }
        pathVariables.put(name, value);
        return this;
    }

    @Override
    public Connection userAgent(String userAgent) {
        Validate.notNull(userAgent, "User agent must not be null");
        req.header("User-Agent", userAgent);
        return this;
    }

    @Override
    public Connection timeout(int millis) {
        req.connectTimeout(millis);
        return this;
    }

    @Override
    public Connection timeout(int connectTimeout, int readTimeout) {
        req.connectTimeout(connectTimeout);
        req.readTimeout(readTimeout);
        return this;
    }

    @Override
    public Connection retry(int maxRetryTimes) {
        req.maxRetryTimes(maxRetryTimes);
        return this;
    }

    @Override
    public Connection method(Method method) {
        req.method(method);
        return this;
    }

    @Override
    public Connection ignoreHttpErrors(boolean ignoreHttpErrors) {
        req.ignoreHttpErrors(ignoreHttpErrors);
        return this;
    }

    @Override
    public Connection ignoreBlankParameters(boolean ignoreBlankParameters) {
        req.ignoreBlankParameters(ignoreBlankParameters);
        return this;
    }

    @Override
    public Connection validateTLSCertificates(boolean value) {
        req.validateTLSCertificates(value);
        return this;
    }

    @Override
    public Connection data(String key, Object value) {
        req.data(KeyVal.create(key, value));
        return this;
    }

    @Override
    public Connection data(String key, String filename, InputStream inputStream) {
        req.data(KeyVal.create(key, filename, inputStream));
        return this;
    }

    @Override
    public Connection data(Map<String, String> data) {
        Validate.notNull(data, "Data map must not be null");
        for (Map.Entry<String, String> entry : data.entrySet()) {
            req.data(KeyVal.create(entry.getKey(), entry.getValue()));
        }
        return this;
    }

    @Override
    public Connection data(String... keyvals) {
        Validate.notNull(keyvals, "Data key value pairs must not be null");
        Validate.isTrue(keyvals.length % 2 == 0, "Must supply an even number of key value pairs");
        for (int i = 0; i < keyvals.length; i += 2) {
            String key = keyvals[i];
            String value = keyvals[i + 1];
            Validate.notEmpty(key, "Data key must not be empty");
            Validate.notNull(value, "Data value must not be null");
            req.data(KeyVal.create(key, value));
        }
        return this;
    }

    @Override
    public Connection data(Collection<Connection.KeyVal> data) {
        Validate.notNull(data, "Data collection must not be null");
        for (Connection.KeyVal entry : data) {
            req.data(entry);
        }
        return this;
    }

    @Override
    public Connection doOutputOnlyOnce(boolean doOutputOnlyOnce) {
        req.doOutputOnlyOnce(doOutputOnlyOnce);
        return this;
    }

    @Override
    public Connection header(String name, String value) {
        req.header(name, value);
        return this;
    }

    @Override
    public Connection contentType(String contentType) {
        req.header("Content-Type", contentType);
        String[] pieces = contentType.split(";");
        if (pieces.length > 1) {
            charset(pieces[1].substring(pieces[1].indexOf("=") + 1));
        }
        return this;
    }

    @Override
    public Connection accept(String accept) {
        req.header("Accept", accept);
        return this;
    }

    @Override
    public Connection cookie(String name, String value) {
        req.cookie(name, value);
        return this;
    }

    @Override
    public Connection cookies(Map<String, String> cookies) {
        Validate.notNull(cookies, "Cookie map must not be null");
        for (Map.Entry<String, String> entry : cookies.entrySet()) {
            req.cookie(entry.getKey(), entry.getValue());
        }
        return this;
    }

    @Override
    public String get() throws IOException {
        req.method(Method.GET);
        execute();
        return res.body();
    }

    @Override
    public String post() throws IOException {
        req.method(Method.POST);
        execute();
        return res.body();
    }

    @Override
    public <T> T get(ResponseCallable<T> callable) throws IOException {
        req.method(Method.GET);
        return execute(callable);
    }

    @Override
    public <T> T post(ResponseCallable<T> callable) throws IOException {
        req.method(Method.POST);
        return execute(callable);
    }

    @Override
    public String post(String body) throws IOException {
        req.body(body);
        req.method(Method.POST);
        execute();
        return res.body();
    }

    @Override
    public <T> T post(String body, ResponseCallable<T> callable) throws IOException {
        req.body(body);
        req.method(Method.POST);
        return execute(callable);
    }

    @Override
    public Connection.Response execute() throws IOException {
        if (req.url() == null) {
            if (pathVariables != null) {
                url = DataUtil.replacePathVariables(url, pathVariables);
            }
            try {
                req.url(new URL(encodeUrl(url)));
            } catch (MalformedURLException e) {
                throw new IllegalArgumentException("Malformed URL: " + url, e);
            }
        }
        res = Response.execute(req);
        return res;
    }

    @Override
    public <T> T execute(ResponseCallable<T> callable) throws IOException {
        if (pathVariables != null) {
            url = DataUtil.replacePathVariables(url, pathVariables);
            try {
                req.url(new URL(encodeUrl(url)));
            } catch (MalformedURLException e) {
                throw new IllegalArgumentException("Malformed URL: " + url, e);
            }
        }
        return Response.execute(req, null, callable).callback();
    }

    @Override
    public Connection.Request request() {
        return req;
    }

    @Override
    public Connection request(Connection.Request request) {
        req = request;
        return this;
    }

    @Override
    public Connection.Response response() {
        return res;
    }

    @Override
    public Connection response(Connection.Response response) {
        res = response;
        return this;
    }

    @Override
    public Connection charset(String charset) {
        req.charset(charset);
        return this;
    }

    @Override
    public Connection signer(Signer signer) {
        req.signer(signer);
        return this;
    }

    @Override
    public Connection test(boolean test) {
        req.test(test);
        return this;
    }

    private static abstract class Base<T extends Connection.Base> implements Connection.Base<T> {

        URL url;
        Method method;
        Map<String, String> headers;
        Map<String, String> cookies;

        private Base() {
            headers = new LinkedHashMap<String, String>();
            cookies = new LinkedHashMap<String, String>();
        }

        @Override
        public URL url() {
            return url;
        }

        @Override
        public T url(URL url) {
            Validate.notNull(url, "URL must not be null");
            this.url = url;
            return (T) this;
        }

        @Override
        public Method method() {
            return method;
        }

        @Override
        public T method(Method method) {
            Validate.notNull(method, "Method must not be null");
            this.method = method;
            return (T) this;
        }

        @Override
        public String header(String name) {
            Validate.notNull(name, "Header name must not be null");
            return getHeaderCaseInsensitive(name);
        }

        @Override
        public T header(String name, String value) {
            Validate.notEmpty(name, "Header name must not be empty");
            Validate.notNull(value, "Header value must not be null");
            removeHeader(name); // ensures we don't get an "accept-encoding" and a "Accept-Encoding"
            headers.put(name, value);
            return (T) this;
        }

        @Override
        public boolean hasHeader(String name) {
            Validate.notEmpty(name, "Header name must not be empty");
            return getHeaderCaseInsensitive(name) != null;
        }

        /**
         * Test if the request has a header with this value (case insensitive).
         */
        @Override
        public boolean hasHeaderWithValue(String name, String value) {
            return hasHeader(name) && header(name).equalsIgnoreCase(value);
        }

        @Override
        public T removeHeader(String name) {
            Validate.notEmpty(name, "Header name must not be empty");
            Map.Entry<String, String> entry = scanHeaders(name); // remove is case insensitive too
            if (entry != null) {
                headers.remove(entry.getKey()); // ensures correct case
            }
            return (T) this;
        }

        @Override
        public Map<String, String> headers() {
            return headers;
        }

        private String getHeaderCaseInsensitive(String name) {
            Validate.notNull(name, "Header name must not be null");
            // quick evals for common case of title case, lower case, then scan for mixed
            String value = headers.get(name);
            if (value == null) {
                value = headers.get(name.toLowerCase());
            }
            if (value == null) {
                Map.Entry<String, String> entry = scanHeaders(name);
                if (entry != null) {
                    value = entry.getValue();
                }
            }
            return value;
        }

        private Map.Entry<String, String> scanHeaders(String name) {
            String lc = name.toLowerCase();
            for (Map.Entry<String, String> entry : headers.entrySet()) {
                if (entry.getKey().toLowerCase().equals(lc)) {
                    return entry;
                }
            }
            return null;
        }

        @Override
        public String cookie(String name) {
            Validate.notEmpty(name, "Cookie name must not be empty");
            return cookies.get(name);
        }

        @Override
        public T cookie(String name, String value) {
            Validate.notEmpty(name, "Cookie name must not be empty");
            Validate.notNull(value, "Cookie value must not be null");
            cookies.put(name, value);
            return (T) this;
        }

        @Override
        public boolean hasCookie(String name) {
            Validate.notEmpty(name, "Cookie name must not be empty");
            return cookies.containsKey(name);
        }

        @Override
        public T removeCookie(String name) {
            Validate.notEmpty(name, "Cookie name must not be empty");
            cookies.remove(name);
            return (T) this;
        }

        @Override
        public Map<String, String> cookies() {
            return cookies;
        }
    }

    public static class Request extends HttpConnection.Base<Connection.Request> implements Connection.Request {

        private int connectTimeoutMilliseconds;
        private int readTimeoutMilliseconds;
        private int maxBodySizeBytes;
        private boolean followRedirects;
        private Collection<Connection.KeyVal> data;
        private String body;
        private boolean ignoreHttpErrors = false;
        private boolean ignoreBlankParameters = true;
        private boolean validateTSLCertificates = true;
        private String charset = DataUtil.defaultCharset;
        private Signer signer;
        private boolean doOutputOnlyOnce = true;
        private boolean test = false;
        private int maxRetryTimes;

        private Request() {
            connectTimeoutMilliseconds = 20000;
            readTimeoutMilliseconds = 40000;
            maxBodySizeBytes = 1024 * 1024; // 1MB
            followRedirects = false;
            data = new TreeSet<Connection.KeyVal>();
            method = Method.GET;
            headers.put("Accept-Encoding", "gzip");
            headers.put("Content-Type", FORM_URL_ENCODED);
        }

        @Override
        public int connectTimeout() {
            return connectTimeoutMilliseconds;
        }

        @Override
        public int readTimeout() {
            return readTimeoutMilliseconds;
        }

        @Override
        public Request connectTimeout(int millis) {
            Validate.isTrue(millis >= 0, "Timeout milliseconds must be 0 (infinite) or greater");
            connectTimeoutMilliseconds = millis;
            return this;
        }

        @Override
        public Request readTimeout(int millis) {
            Validate.isTrue(millis >= 0, "Timeout milliseconds must be 0 (infinite) or greater");
            readTimeoutMilliseconds = millis;
            return this;
        }

        @Override
        public int maxBodySize() {
            return maxBodySizeBytes;
        }

        @Override
        public Connection.Request maxBodySize(int bytes) {
            Validate.isTrue(bytes >= 0, "maxSize must be 0 (unlimited) or larger");
            maxBodySizeBytes = bytes;
            return this;
        }

        @Override
        public boolean followRedirects() {
            return followRedirects;
        }

        @Override
        public Connection.Request followRedirects(boolean followRedirects) {
            this.followRedirects = followRedirects;
            return this;
        }

        @Override
        public boolean ignoreHttpErrors() {
            return ignoreHttpErrors;
        }

        @Override
        public boolean validateTLSCertificates() {
            return validateTSLCertificates;
        }

        @Override
        public void validateTLSCertificates(boolean value) {
            validateTSLCertificates = value;
        }

        @Override
        public Connection.Request ignoreHttpErrors(boolean ignoreHttpErrors) {
            this.ignoreHttpErrors = ignoreHttpErrors;
            return this;
        }

        @Override
        public boolean ignoreBlankParameters() {
            return ignoreBlankParameters;
        }

        @Override
        public Request ignoreBlankParameters(boolean ignoreBlankParameters) {
            this.ignoreBlankParameters = ignoreBlankParameters;
            return this;
        }

        @Override
        public Request data(Connection.KeyVal keyval) {
            Validate.notNull(keyval, "Key val must not be null");
            data.add(keyval);
            return this;
        }

        @Override
        public Collection<Connection.KeyVal> data() {
            return data;
        }

        @Override
        public void body(String body) {
            this.body = body;
        }

        @Override
        public String body() {
            return body;
        }

        @Override
        public boolean hasActualBody() {
            return body != null;
        }

        @Override
        public Connection.Request charset(String charset) {
//            Validate.notNull(charset, "Charset must not be null");
            if (charset != null && !Charset.isSupported(charset)) {
                throw new IllegalCharsetNameException(charset);
            }
            this.charset = charset;
            return this;
        }

        @Override
        public String charset() {
            return charset;
        }

        @Override
        public void signer(Signer signer) {
            this.signer = signer;
        }

        @Override
        public Signer signer() {
            return signer;
        }

        @Override
        public int maxRetryTimes() {
            return maxRetryTimes;
        }

        @Override
        public void maxRetryTimes(int times) {
            this.maxRetryTimes = times;
        }

        @Override
        public void doOutputOnlyOnce(boolean doOutputOnlyOnce) {
            this.doOutputOnlyOnce = doOutputOnlyOnce;
        }

        @Override
        public boolean doOutputOnlyOnce() {
            return doOutputOnlyOnce;
        }

        @Override
        public boolean test() {
            return test;
        }

        @Override
        public void test(boolean test) {
            this.test = test;
        }
    }

    public static class Response<C> extends HttpConnection.Base<Connection.Response<C>> implements Connection.Response<C> {

        private static final int MAX_REDIRECTS = 20;
        private static SSLSocketFactory sslSocketFactory;
        private static final String LOCATION = "Location";
        private int statusCode;
        private String statusMessage;
        private ByteBuffer byteData;
        private String charset;
        private String contentType;
        private boolean executed = false;
        private int numRedirects = 0;
        private C callback;

        Response() {
            super();
        }

        private Response(Response<C> previousResponse) throws IOException {
            super();
            if (previousResponse != null) {
                numRedirects = previousResponse.numRedirects + 1;
                if (numRedirects >= MAX_REDIRECTS) {
                    throw new IOException(String.format("Too many redirects occurred trying to load URL %s", previousResponse.url()));
                }
            }
        }

        static Response execute(Connection.Request req) throws IOException {
            return execute(req, null, null);
        }

        static Response execute(Connection.Request req, Response previousResponse) throws IOException {
            return execute(req, previousResponse, null);
        }

        static <T> Response<T> execute(Connection.Request req, Response<T> previousResponse, ResponseCallable<T> callable) throws IOException {
            Validate.notNull(req, "Request must not be null");
            String protocol = req.url().getProtocol();
            if (!protocol.equals("http") && !protocol.equals("https")) {
                throw new MalformedURLException("Only http & https protocols supported");
            }

            if (req.ignoreBlankParameters()) {
                Iterator<Connection.KeyVal> iterator = req.data().iterator();
                while (iterator.hasNext()) {
                    Connection.KeyVal next = iterator.next();
                    if (DataUtil.isBlank(next.value())) {
                        iterator.remove();
                    }
                }
            }

            // set up the request for execution
            String mimeBoundary = null;
            if (req.data().size() > 0 && (!req.method().hasBody() || req.hasActualBody())) {
                serialiseRequestUrl(req); // appends query string
            } else if (req.method().hasBody()) {
                mimeBoundary = setOutputContentType(req);
            }
            logger.debug("{} {}", req.method(), req.url());
            HttpURLConnection conn = createConnection(req);
            Response<T> res;
            try {
                if (!req.test()) {
                    for (int retryTimes = 0;; retryTimes++) {
                        try {
                            conn.connect();
                            break;
                        } catch (SocketTimeoutException e) {
                            if (KEYWORDS_READ_TIMED_OUT.equals(e.getMessage())) {
                                // Read timed out.  should not re-send.
                                throw e;
                            } else {    // connect timed out
                                if (retryTimes >= req.maxRetryTimes()) {
                                    throw e;
                                } else {
                                    logger.debug("connect timed out - retry again - " + (retryTimes + 1));
                                }
                            }
                        }
                    }
                }
                if (conn.getDoOutput()) {
                    OutputStream os = req.test()
                        ? System.out
                        : conn.getOutputStream();
                    writePost(req, os, mimeBoundary);
                }

                int status = req.test() ? 200 : conn.getResponseCode();
                logger.debug("response status: {}", status);
                res = new Response(previousResponse);
                res.setupFromConnection(conn, previousResponse, req.test());

                // redirect if there's a location header (from 3xx, or 201 etc)
                if (res.hasHeader(LOCATION) && req.followRedirects()) {
                    req.method(Method.GET); // always redirect with a get. any data param from original req are dropped.
                    req.data().clear();

                    String location = res.header(LOCATION);
                    if (location != null && location.startsWith("http:/") && location.charAt(6) != '/') // fix broken Location: http:/temp/AAG_New/en/index.php
                    {
                        location = location.substring(6);
                    }
                    req.url(new URL(req.url(), encodeUrl(location)));
                    for (Object entry : res.cookies.entrySet()) {
                        Map.Entry<String, String> cookie = (Map.Entry<String, String>) entry;
                        req.cookie(cookie.getKey(), cookie.getValue());

                    }
                    logger.debug("redirect to {}", req.url());
                    return execute(req, res, callable);
                }

                res.charset = DataUtil.getCharsetFromContentType(res.contentType); // may be null, readInputStream deals with it
                if (req.test() || conn.getContentLength() != 0) { // -1 means unknown, chunked. sun throws an IO exception on 500 response with no content when trying to read body
                    InputStream bodyStream = null;
                    InputStream dataStream = null;
                    try {
                        dataStream = req.test()
                            ? new ByteArrayInputStream("".getBytes())
                            : (conn.getErrorStream() != null ? conn.getErrorStream() : conn.getInputStream());
                        bodyStream = res.hasHeaderWithValue(CONTENT_ENCODING, "gzip")
                            ? new BufferedInputStream(new GZIPInputStream(dataStream))
                            : new BufferedInputStream(dataStream);
                        res.byteData = DataUtil.readToByteBuffer(bodyStream, req.maxBodySize());
                        if (callable != null && conn.getErrorStream() == null) {
                            res.callback = callable.withBodyStream(bodyStream);
                        }
                    } finally {
                        if (bodyStream != null) {
                            bodyStream.close();
                        }
                        if (dataStream != null) {
                            dataStream.close();
                        }
                    }
                } else {
                    if (conn.getErrorStream() != null) {
                        res.byteData = DataUtil.readToByteBuffer(conn.getErrorStream(), req.maxBodySize());
                    } else {
                        res.byteData = DataUtil.emptyByteBuffer();
                    }
                }
            } finally {
                // per Java's documentation, this is not necessary, and precludes keepalives. However in practise,
                // connection errors will not be released quickly enough and can cause a too many open files error.
                conn.disconnect();
            }

            res.executed = true;

            if (!req.ignoreHttpErrors() && (res.statusCode < 200 || res.statusCode >= 400)) {
                throw new HttpStatusException(res.body(), res.statusCode, req.url().toString());
            }
            return res;
        }

        @Override
        public int statusCode() {
            return statusCode;
        }

        @Override
        public String statusMessage() {
            return statusMessage;
        }

        @Override
        public String charset() {
            return charset;
        }

        @Override
        public String contentType() {
            return contentType;
        }

        @Override
        public String body() {
            Validate.isTrue(executed, "Request must be executed (with .execute(), .get(), or .post() before getting response body");
            // charset gets set from header on execute, and from meta-equiv on parse. parse may not have happened yet
            String body;
            if (charset == null) {
                body = Charset.forName(DataUtil.defaultCharset).decode(byteData).toString();
            } else {
                body = Charset.forName(charset).decode(byteData).toString();
            }
            byteData.rewind();

            logger.debug("Response body: {}", body);
            return body;
        }

        @Override
        public C callback() {
            return callback;
        }

        @Override
        public byte[] bodyAsBytes() {
            Validate.isTrue(executed, "Request must be executed (with .execute(), .get(), or .post() before getting response body");
            return byteData.array();
        }

        // set up connection defaults, and details from request
        private static HttpURLConnection createConnection(Connection.Request req) throws IOException {
            HttpURLConnection conn = (HttpURLConnection) req.url().openConnection();

            conn.setRequestMethod(req.method().name());
            conn.setInstanceFollowRedirects(false); // don't rely on native redirection support
            conn.setConnectTimeout(req.connectTimeout());
            conn.setReadTimeout(req.readTimeout());

            if (conn instanceof HttpsURLConnection) {
                if (!req.validateTLSCertificates()) {
                    initUnSecureTSL();
                    ((HttpsURLConnection) conn).setSSLSocketFactory(sslSocketFactory);
                    ((HttpsURLConnection) conn).setHostnameVerifier(getInsecureVerifier());
                }
            }

            if (req.method().hasBody()) {
                conn.setDoOutput(true);
            }
            if (req.cookies().size() > 0) {
                String cookie = getRequestCookieString(req);
                logger.debug("Cookie : {}", cookie);
                conn.addRequestProperty("Cookie", cookie);
            }
            for (Map.Entry<String, String> header : req.headers().entrySet()) {
                logger.debug("{} : {}", header.getKey(), header.getValue());
                conn.addRequestProperty(header.getKey(), header.getValue());
            }
            return conn;
        }

        /**
         * Instantiate Hostname Verifier that does nothing.
         * This is used for connections with disabled SSL certificates validation.
         *
         *
         * @return Hostname Verifier that does nothing and accepts all hostnames
         */
        private static HostnameVerifier getInsecureVerifier() {
            return new HostnameVerifier() {
                @Override
                public boolean verify(String urlHostName, SSLSession session) {
                    return true;
                }
            };
        }

        /**
         * Initialise Trust manager that does not validate certificate chains and
         * add it to current SSLContext.
         * <p/>
         * please not that this method will only perform action if sslSocketFactory is not yet
         * instantiated.
         *
         * @throws IOException
         */
        private static synchronized void initUnSecureTSL() throws IOException {
            if (sslSocketFactory == null) {
                // Create a trust manager that does not validate certificate chains
                final TrustManager[] trustAllCerts = new TrustManager[]{new X509TrustManager() {

                    @Override
                    public void checkClientTrusted(final X509Certificate[] chain, final String authType) {
                    }

                    @Override
                    public void checkServerTrusted(final X509Certificate[] chain, final String authType) {
                    }

                    @Override
                    public X509Certificate[] getAcceptedIssuers() {
                        return null;
                    }
                }};

                // Install the all-trusting trust manager
                final SSLContext sslContext;
                try {
                    sslContext = SSLContext.getInstance("SSL");
                    sslContext.init(null, trustAllCerts, new java.security.SecureRandom());
                    // Create an ssl socket factory with our all-trusting manager
                    sslSocketFactory = sslContext.getSocketFactory();
                } catch (NoSuchAlgorithmException e) {
                    throw new IOException("Can't create unsecure trust manager");
                } catch (KeyManagementException e) {
                    throw new IOException("Can't create unsecure trust manager");
                }
            }

        }

        // set up url, method, header, cookies
        private <T> void setupFromConnection(HttpURLConnection conn, Connection.Response<T> previousResponse, boolean isTestMode) throws IOException {
            method = Method.valueOf(conn.getRequestMethod());
            url = conn.getURL();
            statusCode = isTestMode ? 200 : conn.getResponseCode();
            statusMessage = isTestMode ? "test mode" : conn.getResponseMessage();
            contentType = conn.getContentType();

            Map<String, List<String>> resHeaders = conn.getHeaderFields();
            processResponseHeaders(resHeaders);

            // if from a redirect, map previous response cookies into this response
            if (previousResponse != null) {
                for (Map.Entry<String, String> prevCookie : previousResponse.cookies().entrySet()) {
                    if (!hasCookie(prevCookie.getKey())) {
                        cookie(prevCookie.getKey(), prevCookie.getValue());
                    }
                }
            }
        }

        void processResponseHeaders(Map<String, List<String>> resHeaders) {
            for (Map.Entry<String, List<String>> entry : resHeaders.entrySet()) {
                String name = entry.getKey();
                if (name == null) {
                    continue; // http/1.1 line
                }
                List<String> values = entry.getValue();
                if (name.equalsIgnoreCase("Set-Cookie")) {
                    for (String value : values) {
                        if (value == null) {
                            continue;
                        }
                        TokenQueue cd = new TokenQueue(value);
                        String cookieName = cd.chompTo("=").trim();
                        String cookieVal = cd.consumeTo(";").trim();
                        // ignores path, date, domain, validateTLSCertificates et al. req'd?
                        // name not blank, value not null
                        if (cookieName.length() > 0) {
                            cookie(cookieName, cookieVal);
                        }
                    }
                } else { // only take the first instance of each header
                    if (!values.isEmpty()) {
                        header(name, values.get(0));
                    }
                }
            }
        }

        private static String setOutputContentType(final Connection.Request req) {
            // multipart mode, for files. add the header if we see something with an inputstream, and return a non-null boundary
            boolean needsMulti = false;
            for (Connection.KeyVal keyVal : req.data()) {
                if (keyVal.hasInputStream()) {
                    needsMulti = true;
                    break;
                }
            }
            String bound = null;
            if (needsMulti) {
                bound = DataUtil.mimeBoundary();
                req.header(CONTENT_TYPE, MULTIPART_FORM_DATA + "; boundary=" + bound);
            } else {
                String contentType = req.header(CONTENT_TYPE);
                if (!contentType.contains(";") && req.charset() != null) {
                    req.header(CONTENT_TYPE, contentType + "; charset=" + req.charset());
                }
            }
            return bound;
        }

        private static void writePost(final Connection.Request req, final OutputStream outputStream, final String bound) throws IOException {
            final Collection<Connection.KeyVal> data = req.data();
            final BufferedWriter w = new BufferedWriter(
                req.charset() == null
                    ? new OutputStreamWriter(outputStream)
                    : new OutputStreamWriter(outputStream, req.charset())
            );

            if (bound != null) {
                // boundary will be set if we're in multipart mode
                for (Connection.KeyVal keyVal : data) {
                    logger.debug("upload multipart: name={} filename={}", keyVal.key(), keyVal.value());
                    w.write("--");
                    w.write(bound);
                    w.write("\r\n");
                    w.write("Content-Disposition: form-data; name=\"");
                    w.write(encodeMimeName(keyVal.key())); // encodes " to %22
                    w.write("\"");
                    if (keyVal.hasInputStream()) {
                        w.write("; filename=\"");
                        w.write(encodeMimeName(keyVal.value()));
                        w.write("\"\r\nContent-Type: application/octet-stream\r\n\r\n");
                        w.flush(); // flush
                        DataUtil.crossStreams(keyVal.inputStream(), outputStream);
                        outputStream.flush();
                    } else {
                        w.write("\r\n\r\n");
                        w.write(keyVal.value());
                    }
                    w.write("\r\n");
                }
                w.write("--");
                w.write(bound);
                w.write("--");
            } else {
                if (req.hasActualBody()) {
                    logger.debug("write body: {}", req.body());
                    w.write(req.body());
                } else {// regular form data (application/x-www-form-urlencoded)
                    if (req.doOutputOnlyOnce()) {
                        StringBuilder builder = new StringBuilder();
                        for (Connection.KeyVal keyVal : data) {
                            writeWithCharset(builder, keyVal.key(), keyVal.value(), req.charset(), false);
                            builder.append("&");
                        }
                        if (!data.isEmpty()) {
                            builder.deleteCharAt(builder.length() - 1);
                        }
                        if (req.signer() != null) {
                            builder.append("&");
                            String sign = req.signer().sign(req.data());
                            writeWithCharset(builder, req.signer().name(), sign, req.charset(), false);
                        }
                        logger.debug("write body: {}", builder);
                        w.write(builder.toString());
                    } else {
                        boolean first = true;
                        for (Connection.KeyVal keyVal : data) {
                            if (!first) {
                                w.append('&');
                            } else {
                                first = false;
                            }
                            writeWithCharset(w, keyVal.key(), keyVal.value(), req.charset(), true);
                        }
                        if (req.signer() != null) {
                            String sign = req.signer().sign(req.data());
                            if (!first) {
                                w.write('&');
                            }
                            writeWithCharset(w, req.signer().name(), sign, req.charset(), true);
                        }
                    }
                }
            }

            w.close();
        }

        private static void writeWithCharset(Appendable w, String key, String value, String charset, boolean writeToBody) throws UnsupportedEncodingException, IOException {
            String k = charset == null ? key : URLEncoder.encode(key, charset);
            if (value == null) {
                value = "";
            }
            String v = charset == null ? value : URLEncoder.encode(value, charset);
            if (writeToBody) {
                logger.debug("write key pair to body: {}={}", k, v);
            }
            w.append(k).append('=').append(v);
        }

        private static String getRequestCookieString(Connection.Request req) {
            StringBuilder sb = new StringBuilder();
            boolean first = true;
            for (Map.Entry<String, String> cookie : req.cookies().entrySet()) {
                if (!first) {
                    sb.append("; ");
                } else {
                    first = false;
                }
                sb.append(cookie.getKey()).append('=').append(cookie.getValue());
                // todo: spec says only ascii, no escaping / encoding defined. validate on set? or escape somehow here?
            }
            return sb.toString();
        }

        // for get url reqs, serialise the data map into the url
        private static void serialiseRequestUrl(Connection.Request req) throws IOException {
            URL in = req.url();
            StringBuilder url = new StringBuilder();
            boolean first = true;
            // reconstitute the query, ready for appends
            url
                .append(in.getProtocol())
                .append("://")
                .append(in.getAuthority()) // includes host, port
                .append(in.getPath())
                .append("?");
            if (in.getQuery() != null) {
                url.append(in.getQuery());
                first = false;
            }
            for (Connection.KeyVal keyVal : req.data()) {
                String v = keyVal.value();
                if (!first) {
                    url.append('&');
                } else {
                    first = false;
                }
                writeWithCharset(url, keyVal.key(), keyVal.value(), req.charset(), false);
            }

            if (req.signer() != null) {
                String sign = req.signer().sign(req.data());
                if (!first) {
                    url.append('&');
                }
                writeWithCharset(url, req.signer().name(), sign, req.charset(), false);
            }
            req.url(new URL(url.toString()));
            req.data().clear(); // moved into url as get params
        }
    }

    public static class KeyVal implements Connection.KeyVal, Comparable<Connection.KeyVal> {

        private String key;
        private String value;
        private InputStream stream;

        public static KeyVal create(String key, Object value) {
            return new KeyVal().key(key).value((value == null) ? "" : value.toString());
        }

        public static KeyVal create(String key, String filename, InputStream stream) {
            return new KeyVal().key(key).value(filename).inputStream(stream);
        }

        private KeyVal() {
        }

        @Override
        public KeyVal key(String key) {
            Validate.notEmpty(key, "Data key must not be empty");
            this.key = key;
            return this;
        }

        @Override
        public String key() {
            return key;
        }

        @Override
        public KeyVal value(String value) {
            Validate.notNull(value, "Data value must not be null");
            this.value = value;
            return this;
        }

        @Override
        public String value() {
            return value;
        }

        @Override
        public KeyVal inputStream(InputStream inputStream) {
            Validate.notNull(value, "Data input stream must not be null");
            this.stream = inputStream;
            return this;
        }

        @Override
        public InputStream inputStream() {
            return stream;
        }

        @Override
        public boolean hasInputStream() {
            return stream != null;
        }

        @Override
        public String toString() {
            return key + "=" + value;
        }

        /**
         * Sort by key-value's nature order.
         *
         * {@inheritDoc }
         */
        @Override
        public int compareTo(Connection.KeyVal o) {
            int c = this.key.compareTo(o.key());
            return c != 0 ? c : this.value.compareTo(o.value());
        }
    }
    private static final String KEYWORDS_READ_TIMED_OUT = "Read timed out";
}
